cover photo
updated on Feb 13, 2025

Simplifying AJV Schema Validation with TypeScript

References

About the Package

AJV is a popular open-source library for validating data against JSON Schema. It is widely used in JavaScript and TypeScript projects to ensure data integrity, particularly in API request validation, configuration files, and structured data processing.

Although the AJV library offers TypeScript support, it doesn’t provide built-in tools for defining schemas in TypeScript. To bridge this gap, I’ve published an npm package called @raminyavari/ajv-ts-schema. This package simplifies schema definition while maintaining TypeScript compatibility.

Here’s how it works. Instead of:

const schema = {
  type: "object",
  properties: {
    foo: {
      type: "number",
      multipleOf: 5,
      minimum: 0,
      maximum: 100,
    },
    bar: {
      type: "string",
      nullable: true,
      maxLength: 20,
    }
  },
  required: ["foo"]
};

You can do this:

@AjvObject()
class MySchema extends AjvSchema {
  @AjvProperty({ 
    type: "number", 
    multipleOf: 5, 
    minimum: 0, 
    maximum: 100, 
    required: true,
  })
  foo!: number;
  
  @AjvProperty({ type: "string", nullable: true, maxLength: 20 })
  bar?: string | null;
}

const schema = mySchema.getSchema(); // This gives you the AJV JSON schema

The key advantage of the second approach over the first is that it is fully typed, offering several significant benefits:

  • Code Completion: Your IDE or code editor provides intelligent suggestions as you type, making development faster and more accurate.
  • Error Prevention: Type checking ensures that mistakes are caught during development, reducing runtime errors.
  • Helpful Documentation: Each option includes detailed tips with valid and invalid examples, visible when you hover over the option or view code completion suggestions.
  • Re-usability: The defined schema isn’t limited to validation—it can also be leveraged when processing incoming requests. This versatility will be explored later in the article.

How Does It Work?

To get started, install the package by running one of the following commands:

npm install @raminyavari/ajv-ts-schema

Or, if you’re using Yarn:

yarn add @raminyavari/ajv-ts-schema

This package uses experimental decorators and includes reflect-metadata as a dependency. To use it correctly, you must enable experimentalDecorators and emitDecoratorMetadata in your tsconfig.json. Here's how to configure it:

{
  "compilerOptions": {
    "target": "ES6", // or higher
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

If you want to use this package along with ajv, it is recommended to also install ajv-formats and use ajv draft 2020. Here is how to use along with ajv:

import Ajv from "ajv/dist/2020";
import addFormats from "ajv-formats";
import { 
	AjvSchema, 
	AjvObject, 
	AjvProperty, 
	getSchema,
	type AjvJsonSchema
} from "@raminyavari/ajv-ts-schema";

const ajv = new Ajv({ useDefaults: true });
addFormats(ajv);

@AvjObject({ /* ...options */ })
class MySchema extends AjvSchema {
  @AjvProperty({ type: "string" /*, ...string options */ })
  foo?: string;
  
  // ...other properties
}

const myController = (input: any) => {
  const schema = MySchema.getSchema();
  const validate = ajv.compile(schema);
  const isValid = validate(input);
  
  if (!isValid) {
	  const errors = validate.errors;
	  return { }; // BadRequest with the list of errors
  }
  
  const typedInput = AjvSchema.fromJson(MySchema, input); // returns an instance of MySchema
  
  // the rest of the code
};

/** 
 * We can also add a type-guard function as follows.
 * If this function returns 'true' the rest of the code
 * considers the input as a JSON object that matches 'MySchema'
 */
const isMySchema = (input: any): input is AjvJsonSchema<MySchema> => {
	return validate(input);
}

Package Overview

The package provides the following key exports:

  • AjvSchema: A base class for defining all your schemas. It includes two static methods, getSchema and fromJson, which are explained later.
  • AjvObject: A class decorator used to mark a class as a schema.
  • AjvProperty: A property decorator for defining schema properties.
  • getSchema: A utility function for converting property schemas into an AJV schema. While the expected input is typically a JSON object, you can also use getSchema for primitives and arrays.
  • AjvJsonSchema: Which is a generic type that takes a schema and generates a TypeScript object type that conforms to the schema's structure.

An AJV schema is a JSON object that defines a type along with its associated options. It typically looks like this:

{
  type: "object" | "string" | "number"| "integer" | "boolean" | "array",
  ...options
}

Each type in AJV schemas has a unique set of options. For example, the object type refers to a JSON object, meaning the input must conform to a JSON structure. Here's an example:

schema: { type: "object", properties: { foo: { type: "string" } } }

input: { foo: "abc" }

With @raminyavari/ajv-ts-schema, the object type is represented as a class that extends AjvSchema and is decorated with @AjvObject. Each property of the object is represented by a class property, decorated with @AjvProperty.

This transforms the schema above into the following TypeScript representation:

@AjvObject()
class MySchema extends AjvSchema {
  @AjvProperty({ type: "string" })
  foo?: string;
}

You can retrieve the schema using MySchema.getSchema().

However, there are cases where the input is a primitive type or an array. Since these are not objects, they cannot be represented using a class. For example:

schema: { type: "string", minLength: 2 }

input: "abc"

In such scenarios, you can use the getSchema function. Here's how:

const schema = getSchema({ type: "string", minLength: 2 });

You might notice that in this example, the input and output of getSchema appear to be identical. However, this is not always the case. The key advantage of using getSchema is that it is fully typed, ensuring type safety and preventing errors during development.

Serializing a Validated JSON Object to a Fully Typed Schema

The AjvSchema class provides a static method, fromJson, which converts a validated JSON object into your defined schema.

Here’s an example:

@AjvObject({ ...options })
class MySchema extends AjvSchema {
  @AjvProperty({ type: "formatted-string", format: "email" })
  email?: string;
}

const myController = (input: any) => {
  const schema = MySchema.getSchema();
  const validate = ajv.compile(schema);
  const isValid = validate(input);
  
  if (!isValid) return; // return a bad request with error messages
  
  // Serialize the 'input' to an instance of 'MySchema' 
  const typedInput = AjvSchema.fromJson(MySchema, input);
}

Options

The options are based on those outlined in the AJV documentation: https://github.com/ajv-validator/ajv/blob/master/docs/json-schema.md. However, there are some important differences to note.

Below is a list of key differences and considerations:

Coverage

While not all options and customizations are supported, the first version of the package provides comprehensive coverage, addressing nearly all common use cases—except for very specific edge cases.

Required Properties

In AJV schemas, required is a property of the schema when the type is object.

const schema = {
  type: "object",
  properties: {
    foo: { type: "string" },
    bar: { type: "integer" }
  },
  required: ["foo"]
}

In the package, required is an option applied to the property within the schema.

@AjvObject()
class MySchema extends AjvSchema {
  @AjvProperty({ type: "string", required: true })
  foo!: string;
  
  @AjvProperty({ type: "integer" })
  bar?: number;
}

String with Format

In AJV schemas, a string can have either a pattern or a format option, but not both simultaneously.

const stringWithPattern = { type: "string", pattern: "^x.*$" };
const stringWithFormat = { type: "string", format: "email" };

In the package, a string with a format is transformed into a property of type formatted-string. Here’s an example:

@AvjProperty({ type: "string", pattern: "^x.*$" })
foo!: string;

@AvjProperty({ type: "formatted-string", format: "email" })
bar!: string;

Property Dependencies

Schemas of type object in AJV can use the dependentRequired option, available in Draft 2020. This option specifies that the requirement of certain properties depends on the presence of other properties. Here’s an example:

@AjvObject<MySchema>({ dependentRequired: { foo: ["bar", "baz"] } })
class MySchema extends AjvSchema {
  @AjvProperty({ type: "integer" })
  foo?: number;
  
  @AjvProperty({ type: "integer" })
  bar?: number;
  
  @AjvProperty({ type: "integer" })
  baz?: number;
}

The first thing to note is that the class is passed as a generic type parameter (@AjvObject). This allows dependentRequired to leverage the type parameter for type safety, ensuring that only valid dependencies can be specified—random values cannot be added as dependencies.

Additionally, none of these properties are inherently required. When foo is marked as dependent on bar and baz, it means that if foo is provided, the other two must also be included. If either bar or baz is missing, the input will be considered invalid.

Properties of Type Object

A property can be an object, in which case it must first be defined as a schema.

As an example, if the desired AJV schema is:

{
  type: "object",
  properties: {
	  id: { type: "string" }
    category: {
	    type: "object",
	    properties: {
	      values: { 
		      type: "array", 
		      item: { 
			      type: "object",
			      properties: {
				      id: { type: "integer" },
				      title: { type: "string" }
			      }
			    } 
			  }
	    }
    }
  }
}

It should be defined as follows:

@AjvObject()
class ValueSchema extends AjvSchema {  
  @AjvProperty({ type: "integer" })
  id?: number;
  
  @AjvProperty({ type: "string" })
  title?: string;
}

@AjvObject()
class CategorySchema extends AjvSchema {  
  @AjvProperty({ type: "array", items: ValueSchema })  
  values?: ValueSchema[];
}

@AjvObject()
class MySchema extends AjvSchema {  
  @AjvProperty({ type: "string" })  
  id?: string;
  
  @AjvProperty(CategorySchema)
  category?: CategorySchema;
}

You can then obtain the JSON representation of the schema by calling MySchema.getSchema().

Supported Options

For more details about the options below, refer to the following documentation or check the tooltips provided by your code editor’s auto-completion feature.

AJV JSON Schema

General Options

Below is a list of options available for all types when using @AjvObject and @AjvProperty:

required, nullable, enum, const, default, not, oneOf, anyOf, and allOf.

string

minLength, maxLength, and pattern.

formatted-string

minLength, maxLength, and format.

number & integer

minimum, maximum, exclusiveMinimum, exclusiveMaximum, and multipleOf.

array

minItems, maxItems, uniqueItems, prefixItems, items, contains, minContains, and maxContains.

object

minProperties, maxProperties, patternProperties, additionalProperties, and dependentProperties.